多執行緒讓 CPU 可以同時多工工作,雖然可以提升 CPU 使用效率、減少資源浪費,但也潛藏著風險,其中最著名的為「資料競爭(data race)」。
註:可能很多文章會把資料競爭(data race)與競賽條件(race condition)混為一談,但兩者事實上並不相等,本篇先介紹「資料競爭」,待下一篇講完資料競爭的解決方案後,將發現這些方案尚不足以防範競賽條件,詳細於 Day 24 與 Day 25 文章中說明。
只要滿足下列條件,就能形成資料競爭:
以下來看個常見範例。
在以下程式碼中,有兩個執行緒 t1 與 t2 都要跑 addOneManyTimes() 函數,而這兩個執行緒:
counter 全域變數。counter。其中「寫入」新資料到 counter 這件事在 addOneManyTimes() 函數中進行,兩執行緒透過迴圈、各自把 counter 連續做十萬次 +1,最後以 cout 顯示運算完的結果:
#include <iostream>
#include <thread>
int counter = 0;
void addOneManyTimes() {
for (int i = 0; i < 100000; ++i) {
++counter;
}
}
int main() {
std::thread t1(addOneManyTimes);
std::thread t2(addOneManyTimes);
t1.join();
t2.join();
std::cout << "counter = " << counter << '\n';
return 0;
}
兩個執行緒各加十萬次,所以最後的 counter 應該是 200000 對吧?但最終的結果並非如此,如在我的實驗中,第一次結果如下:
counter = 107479
第二次結果如下:
counter = 112398
第三次結果如下:
counter = 126925
每次結果都不一樣,但每次都不是預期的結果 200000,為什麼會這樣?
一個執行緒要改變 counter 變數的值,都會經過以下三步驟:
counter 目前的值放到某暫存器。counter 從暫存器寫回記憶體。但現在有 t1 與 t2 兩個執行緒,因此變成:
t1 讀取到 counter = 0 並放到暫存器。t2 讀取到 counter = 0 並放到暫存器。t1 在暫存器將讀取到的 0 加 1,並將新 counter 值寫回記憶體。t2 在暫存器將讀取到的 0 加 1,並將新 counter 值寫回記憶體。兩個執行緒都有 +1 沒錯,但兩者都是基於舊值運算,因此其中一次的加 1 就默默被吃掉了,這就是「資料競爭」。